Micron Document
🎖️GitЯра🎖️

Commit d102ca262a5e22d3fbad6b994a004212ef18e750


Parents : 730a02d
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T03:07:03Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T03:07:03Z

fix(service): stop blocking getString on Dispatchers.Default-reachable notification paths (#6668)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
index 7e4046123f..99120129d0 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
@@ -72,7 +72,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
override fun showClientNotification(clientNotification: ClientNotification) {}
- override fun cancelMessageNotification(contactKey: String) {}
+ override suspend fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
index 098843b09b..4d72e52ba4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
@@ -71,7 +71,11 @@ interface MeshNotificationManager {
fun showClientNotification(clientNotification: ClientNotification)
- fun cancelMessageNotification(contactKey: String)
+ /**
+ * Suspending because Android rebuilds the group summary here, and the summary's labels come from string resources —
+ * resolving them must not block the caller's thread (see [org.meshtastic.core.resources.getStringSuspend]).
+ */
+ suspend fun cancelMessageNotification(contactKey: String)
/**
* Called after an inline notification reply has been sent and persisted. Platforms that can should re-post the

diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
index 09d0df60ed..9d5efa5d58 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
@@ -25,7 +25,6 @@ import dev.mokkery.answering.throws
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
-import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
@@ -98,7 +97,7 @@ class ReplyReceiverTest {
verifySuspend { packetRepository.clearUnreadCount(contactKey, any()) }
// The conversation is re-posted with the sent reply (MessagingStyle confirmation flow), not dismissed.
verifySuspend { notificationManager.refreshConversationAfterReply(contactKey) }
- verify(mode = VerifyMode.exactly(0)) { notificationManager.cancelMessageNotification(any()) }
+ verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.cancelMessageNotification(any()) }
}
@Test
@@ -109,7 +108,7 @@ class ReplyReceiverTest {
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), replyIntent(contactKey, "hi"))
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.clearUnreadCount(any(), any()) }
- verify { notificationManager.cancelMessageNotification(contactKey) }
+ verifySuspend { notificationManager.cancelMessageNotification(contactKey) }
}
@Test

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
index 3d3bd820e3..c7c8e83aa6 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
@@ -26,12 +26,14 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.resources.R.drawable
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_mesh_beacon_notifications
@@ -52,23 +54,29 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
* Tracks whether notification channels have been created.
*
* Channels are **not** created in the constructor because this singleton is instantiated by Koin during
- * [org.meshtastic.core.service.MeshService.onCreate] on the main thread. The CMP [getString] helper uses
- * [kotlinx.coroutines.runBlocking] which can fail in that context, crashing the entire service startup chain.
- * Instead, channels are lazily ensured before the first [dispatch] call. Note that
+ * [org.meshtastic.core.service.MeshService.onCreate] on the main thread, and channel names come from string
+ * resources. Instead, channels are lazily ensured before the first [dispatch] call. Note that
* [MeshNotificationManagerImpl.initChannels] already creates a superset of these channels when the orchestrator
* starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization.
+ *
+ * The mutex is load-bearing: resolving the names suspends, so without it two concurrent [dispatch] calls could both
+ * pass the flag check and post before the channels exist.
*/
private var channelsInitialized = false
+ private val channelInitMutex = Mutex()
- private fun ensureChannelsInitialized() {
- if (channelsInitialized) return
+ private suspend fun ensureChannelsInitialized() = channelInitMutex.withLock {
+ if (channelsInitialized) return@withLock
channelsInitialized = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels =
listOf(
createChannel(Notification.Category.Message, Res.string.meshtastic_messages_notifications),
createChannel(Notification.Category.NodeEvent, Res.string.meshtastic_new_nodes_notifications),
- createChannel(Notification.Category.MeshBeacon, Res.string.meshtastic_mesh_beacon_notifications),
+ createChannel(
+ Notification.Category.MeshBeacon,
+ Res.string.meshtastic_mesh_beacon_notifications,
+ ),
createChannel(Notification.Category.Battery, Res.string.meshtastic_low_battery_notifications),
createChannel(Notification.Category.Alert, Res.string.meshtastic_alerts_notifications),
createChannel(Notification.Category.Service, Res.string.meshtastic_service_notifications),
@@ -78,12 +86,12 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
}
}
- private fun createChannel(
+ private suspend fun createChannel(
category: Notification.Category,
nameRes: org.jetbrains.compose.resources.StringResource,
): NotificationChannel {
val channelConfig = category.channelConfig()
- return NotificationChannel(channelConfig.id, getString(nameRes), channelConfig.importance)
+ return NotificationChannel(channelConfig.id, getStringSuspend(nameRes), channelConfig.importance)
}
// Keep category-to-channel mapping aligned with MeshNotificationManagerImpl.NotificationType IDs.

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt
index ee2cf3e8ea..4af8256658 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt
@@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.proto.ChannelSet
import java.util.concurrent.ConcurrentHashMap
@@ -246,7 +247,7 @@ class ConversationShortcutPublisher(
* `setShortcutId`/`setLocusId` resolve immediately (the observer replaces it with a richer version on its next
* emission).
*/
- fun ensureConversationShortcut(contactKey: String, displayName: String) {
+ suspend fun ensureConversationShortcut(contactKey: String, displayName: String) {
// Protect this conversation from the observer's pruning until a contacts snapshot includes it.
pendingOnDemandIds += contactKey
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
@@ -254,7 +255,7 @@ class ConversationShortcutPublisher(
// ShortcutInfoCompat.Builder.build() rejects a blank short label. On-demand callers derive the name from packet
// metadata that can be empty (an unnamed node, or a reaction that arrives before the NodeDB knows the sender),
// so fall back to the same localized placeholder the observer uses rather than letting build() throw.
- val label = displayName.takeIf { it.isNotBlank() } ?: getString(Res.string.unknown_username)
+ val label = displayName.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.unknown_username)
// Match the styling the observer will republish so there is no generic-head flash: rounded channel badge with
// its number, or a circular initial for a DM. Color is derived from the key (the node object may not be known
// yet at on-demand time).

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
index 5ff5b3a04a..7fe7e8f40a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
@@ -274,6 +274,10 @@ class MeshNotificationManagerImpl(
/**
* Creates all necessary notification channels on devices running Android O or newer. This should be called once
* when the service is created.
+ *
+ * Deliberately blocking (Main-thread, one-time cost): the orchestrator posts the foreground-service notification
+ * synchronously right after this returns, so channels must exist before then — do not lazy-gate this into the
+ * suspend notify paths. Blocking [getString] is safe here only because Main is not a Dispatchers.Default worker.
*/
override fun initChannels() {
notificationManager.removeLegacyCategoryChannels()
@@ -547,7 +551,7 @@ class MeshNotificationManagerImpl(
showGroupSummary()
}
- private fun showGroupSummary(justCancelledId: Int? = null) {
+ private suspend fun showGroupSummary(justCancelledId: Int? = null) {
// Exclude the summary itself by its group-summary flag rather than by id, so a conversation whose
// contactKey.hashCode() happens to equal SUMMARY_ID is still counted as an active conversation.
// Also exclude a conversation we just cancelled: activeNotifications does not reflect a cancel() issued
@@ -568,7 +572,7 @@ class MeshNotificationManagerImpl(
}
val ourNode = nodeRepository.value.ourNodeInfo.value
- val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
+ val meName = ourNode?.user?.long_name ?: getStringSuspend(Res.string.you)
val me =
Person.Builder()
.setName(meName)
@@ -583,7 +587,7 @@ class MeshNotificationManagerImpl(
val messagingStyle =
NotificationCompat.MessagingStyle(me)
.setGroupConversation(true)
- .setConversationTitle(getString(Res.string.meshtastic_app_name))
+ .setConversationTitle(getStringSuspend(Res.string.meshtastic_app_name))
activeNotifications.forEach { sbn ->
// Prefer the child's real MessagingStyle: its latest message carries the actual sender (Person, icon) and
@@ -593,7 +597,7 @@ class MeshNotificationManagerImpl(
?.messages
?.lastOrNull()
if (latest?.text != null) {
- val senderPerson = latest.person ?: Person.Builder().setName(getString(Res.string.you)).build()
+ val senderPerson = latest.person ?: Person.Builder().setName(getStringSuspend(Res.string.you)).build()
messagingStyle.addMessage(latest.text, latest.timestamp, senderPerson)
} else {
// Fallback for children without an extractable style: rebuild a generic line from the extras.
@@ -642,7 +646,7 @@ class MeshNotificationManagerImpl(
notificationManager.notify(TAG_CLIENT, clientNotification.toString().hashCode(), notification)
}
- override fun cancelMessageNotification(contactKey: String) {
+ override suspend fun cancelMessageNotification(contactKey: String) {
val id = contactKey.hashCode()
notificationManager.cancel(TAG_MESSAGE, id)
// Rebuild (or clear) the group summary so it doesn't keep showing the dismissed conversation in Android Auto.
@@ -666,13 +670,13 @@ class MeshNotificationManagerImpl(
val index = contactKey.substringBefore(NodeAddress.ID_BROADCAST).toIntOrNull()
channelName = index?.let { channelSet.settings.getOrNull(it) }?.let { Channel(it, lora).name }
// Never fall back to the raw contactKey for user-facing labels (privacy-first convention).
- conversationName = channelName ?: getString(Res.string.channel)
+ conversationName = channelName ?: getStringSuspend(Res.string.channel)
} else {
// DM contactKey is "<channelIndex><userId>" where userId starts at the '!'.
val userId = "!" + contactKey.substringAfter("!", missingDelimiterValue = "")
val peer = nodeRepository.value.nodeDBbyNum.value.values.find { it.user.id == userId }
conversationName =
- peer?.user?.long_name?.takeIf { it.isNotBlank() } ?: getString(Res.string.unknown_username)
+ peer?.user?.long_name?.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.unknown_username)
}
showConversationNotification(contactKey, isBroadcast, channelName, conversationName, isSilent = true)
}
@@ -715,7 +719,7 @@ class MeshNotificationManagerImpl(
}
@Suppress("LongMethod")
- private fun createConversationNotification(
+ private suspend fun createConversationNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
@@ -730,7 +734,7 @@ class MeshNotificationManagerImpl(
}
val ourNode = nodeRepository.value.ourNodeInfo.value
- val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
+ val meName = ourNode?.user?.long_name ?: getStringSuspend(Res.string.you)
val me =
Person.Builder()
.setName(meName)
@@ -964,8 +968,8 @@ class MeshNotificationManagerImpl(
}
}
- private fun createReplyAction(contactKey: String): NotificationCompat.Action {
- val replyLabel = getString(Res.string.reply)
+ private suspend fun createReplyAction(contactKey: String): NotificationCompat.Action {
+ val replyLabel = getStringSuspend(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
val replyIntent =
@@ -989,8 +993,8 @@ class MeshNotificationManagerImpl(
.build()
}
- private fun createMarkAsReadAction(contactKey: String): NotificationCompat.Action {
- val label = getString(Res.string.mark_as_read)
+ private suspend fun createMarkAsReadAction(contactKey: String): NotificationCompat.Action {
+ val label = getStringSuspend(Res.string.mark_as_read)
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = MARK_AS_READ_ACTION

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
index 98d3915240..c33d51273b 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt
@@ -65,7 +65,7 @@ class FakeMeshNotificationManager : MeshNotificationManager {
override fun showClientNotification(clientNotification: ClientNotification) {}
- override fun cancelMessageNotification(contactKey: String) {}
+ override suspend fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
index 0243aad27b..50a98858da 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt
@@ -168,7 +168,7 @@ class DesktopMeshNotificationManager(
}
}
- override fun cancelMessageNotification(contactKey: String) {
+ override suspend fun cancelMessageNotification(contactKey: String) {
notificationManager.cancel(contactKey.hashCode())
}

Served by rngit 1.5.0 - Generated in 0.12s